Skip to content

Space flight layer: ship/asteroid collision, mining, and composite asteroid destruction - #9

Draft
LundstedtAdam wants to merge 22 commits into
storylinefrom
space
Draft

Space flight layer: ship/asteroid collision, mining, and composite asteroid destruction#9
LundstedtAdam wants to merge 22 commits into
storylinefrom
space

Conversation

@LundstedtAdam

Copy link
Copy Markdown
Owner

Summary

Builds the space-flight layer on top of storyline: ship-vs-planet/asteroid collision, a deterministic structured asteroid belt with per-instance state and a spatial grid, an automatic-fire mining/weapon system, and a space narrative/POI layer — capped off by a full rework of asteroid destruction into a genuine composite-physical-body system per explicit user direction (not "delete + spawn generic replacement").

Asteroid destruction rework (the bulk of recent work)

  • Promotion + local dents: the first damaging hit on an eligible (non-dust-tier, budget-permitting) asteroid promotes it out of the shared InstancedMesh pool into a standalone, individually-mutable mesh, so sub-lethal hits leave a real, persistent, deepening crater at the impact point instead of silently decrementing health.
  • Pattern-based fracture + real momentum: lethal hits extract real clusters of the asteroid's own (possibly already-dented) geometry as fragments, precomputed once per base shape via Voronoi-on-sphere face clustering. Fragment velocity/spin follow rigid-body momentum (parent linear + angular velocity at the fragment's offset, plus the impact's own direction/force) instead of pure jitter.
  • Cascade bounce + secondary fracture: debris now reflects off planets/asteroids/other debris (real restitution + tangential energy bleed) instead of sticking-and-expiring on first contact. A hard-enough bounce chips 1-2 small secondary fragments off, capped to exactly one extra cascade level so population growth stays bounded.
  • Bucketed debris rendering: fragments render in per-shape InstancedMesh buckets (keyed by tier/variant/pattern/cluster), extracted from the belt's shared base geometry, so a fragment visually reads as the actual chunk it broke off instead of a generic rock.

Also in this branch

  • Ship-vs-planet/moon and ship-vs-asteroid collision (analytic sphere contact, slide response tuned for piloting feel).
  • Deterministic, structured asteroid belt placement with per-asteroid identity, health, and a spatial grid for broadphase queries.
  • Discrete automatic-fire mining/weapon system (visible flash/tracer, impact spark chips, physical knockback) replacing an earlier continuous damage-over-time beam per user feedback.
  • Space narrative/POI layer, touch controls (roll, fire/mine), mining crosshair/telemetry, audio feedback, and quality-tier budget tuning throughout.

Test plan

  • npx tsc --noEmit — clean
  • npm run lint — clean
  • npm test (vitest) — 208 tests passing
  • npm run build (tsc + vite production build) — succeeds
  • Live-device WebGPU check needed (this sandbox can't render WebGPU): dent visual quality, whether extracted fragments read as "broken off this rock," frame cost with several concurrent promoted/dented asteroids, and whether the bucketed-InstancedMesh debris rendering pops/flickers as new shape buckets spin up mid-flight.

Generated by Claude Code

claude added 15 commits July 5, 2026 13:47
Ship now slides off planets/moons on contact (analytic sphere test,
push-out + tangential-velocity retention) instead of flying through
them, gated to piloting mode so descent/ascent cinematics are
untouched. No acceleration term is added, so the deliberate "no
gravity well" flight model is preserved.

Also fixes the thruster glow (previously a random flicker unrelated to
throttle) to track actual throttle magnitude, adds a pooled
engine-exhaust particle trail, and an engine hum whose gain/pitch
track throttle — all reading the existing shipTelemetry hot-path
singleton rather than the reactive store.
Replace the belt's unseeded Math.random() generation with a pure
function of (tier, variant, index, seed) using the existing
cellHash/seedFromName primitives, so the layout no longer reshuffles
on every quality-tier change and a given index always resolves to the
same physical rock — required for narrative content to anchor to a
specific asteroid later, and for any future collision/fracture state
to stay in sync with the render matrices.

Also folds in structured (sector-weighted) placement: 24 angular
sectors get a deterministic density multiplier (gap/sparse/normal/
dense), biasing which sector an index lands in without changing how
many asteroids exist per tier, so gaps read as flyable corridors
instead of uniform noise.
Asteroids get a parallel plain-array-of-structs (position, radius,
health, alive/indestructible flags) index-aligned with each tier's
InstancedMesh, built via the same deterministic placement the belt
already uses so the state array and render matrices never disagree
about which (tier, variant, i) exists.

A cylindrical spatial grid (radial x angular bins, sized for the
belt's thin-torus shape) gives O(1)-amortized nearby-asteroid queries
instead of scanning up to 9000 instances. Both are published on a new
asteroidRuntime singleton (shipTelemetry's hot-path-singleton
convention) along with a killAsteroid callback that zeroes a dead
asteroid's render instance and drops it from the grid, so upcoming
collision/mining/fracture code can destroy an asteroid without
reaching into AsteroidBelt's internal Three.js objects.
Ship now slides off asteroids on contact using the same push-out +
tangential-retain response as planet collision, broadphased through
the belt's spatial grid instead of scanning every instance. Since the
grid is indexed in belt-local space (the belt rotates as one group
each frame), the query/response rotate the ship's position and
velocity into that frame and back via the belt's published yaw.

Generalizes the previously-private sphere-vs-sphere resolver into a
shared resolveSphereContact used by both planet and asteroid contacts,
and returns hit info (which asteroid, contact point, closing speed)
for the upcoming collision-triggered damage in the fracture/mining
systems.
Asteroids now hold real health instead of being binary objects: a hit
that doesn't finish one off just wears it down (no debris); a lethal
hit spawns 1-5 debris fragments, or 4-8 on a big-overkill hit, each
with a deterministically hashed spawn direction/size (not
Math.random()) so a given hit's outcome is reproducible. Debris never
recursively fractures — a hard budget stop. Both collision (a fast
graze now chips an asteroid's health) and the upcoming mining/weapon
system route through this same applyAsteroidDamage pipeline.

Debris is a pooled, quality-budgeted population (AsteroidDebris.tsx,
same InstancedMesh swap-remove pattern as the voxel mining debris
burst) that drifts freely in world space via the ship's existing
integrate() helper, sticks-and-expires on any contact (planet,
asteroid, other debris) rather than continuing to bounce, and is
force-culled by lifetime and distance from the ship — all four budgets
are new QUALITY tiers.
Raycasts a fixed screen-center beam (mirroring the voxel mining
crosshair convention) against grid-shortlisted asteroid candidates
only, feeding sustained fire into the same applyAsteroidDamage
pipeline collision damage uses — holding the beam on target naturally
produces low/medium/high fracture outcomes depending on how long it
stays on target. Firing binds to left mouse (while pointer-locked),
Space, or gamepad RB, none of which conflict with existing flight
controls.

A fraction of any fracture's debris (mining- or collision-triggered)
comes back flagged as ore with a resource type from the existing
inventory system; ore chunks within range home toward the ship (a
one-way pull on the small pickup object, not a force on the ship, so
it doesn't reintroduce a gravity well) and are collected into the same
backpack voxel mining uses.
Adds a small, Act-1-scoped set of space points of interest —
wreckage, a signal anomaly, a resource cluster, and a distant
landmark — reusing the existing two-layer discovery architecture
exactly as-is (recordDiscovery with planet: 'space', the existing
'signal' cross-body mystery) rather than a parallel narrative system.
No new store field was needed: the existing `discovered` map already
namespaces by "planet:id", so 'space' is just another valid planet
string.

Wreckage can anchor to a specific, stable belt asteroid (guaranteed to
resolve to the same physical rock by the belt's determinism contract)
and flags its host indestructible so it can't be blown apart by
fracture testing. Discovery is proximity-triggered while piloting
(fly close enough, no separate scan-button UI) — simpler than the
voxel-surface manual-scan convention, appropriate since these are
found while flying rather than on foot.
Adds Phase 11.8 covering ship collision, deterministic/structured
asteroid placement, fracture/debris, space mining, and the space
narrative/POI layer built on this branch, and notes the branch itself
in the Branches & PRs section — unmerged, not yet manually
playtested.
Fixes two real gaps: touch/mobile had no roll control at all (the
on-screen joystick only ever set yaw/pitch, roll was hardcoded to 0),
and touch had no way to fire the mining beam whatsoever (isFiring()
only checked mouse/keyboard/gamepad) — independent of any range or
quality-tier questions, touch players simply could not shoot or mine.

- New ↺/↻ roll buttons and a FIRE button in TouchControls.tsx, grouped
  as a bottom-right thumb-reach cluster mirroring the game's own
  on-foot action-button convention (VoxelTouchControls.tsx), wired
  through new setTouchRoll/setTouchFiring exports.
- Space mining now has visible feedback for every input method: a
  crosshair that highlights on-target (raycasts every frame instead of
  only while firing, published via a new spaceMiningTelemetry
  singleton), a visual beam line while firing, and an audible beam
  tone distinct from the engine hum.
- Documented the fire control and touch roll buttons in ControlHints.tsx
  (English + Swedish), which previously never mentioned firing at all.
… knockback

Firing is now a fixed-cadence stream of discrete hitscan shots while
the trigger is held (6/sec, first shot immediate on trigger-pull)
instead of a continuous damage-over-time beam — each shot flashes the
tracer line briefly, plays a distinct laser blip (pitched differently
for a hit vs. a miss), and, on a connecting hit, bursts a handful of
short-lived impact-chip sparks at the point of impact regardless of
whether the hit fractures the target. Sparks are a separate, cheap,
quality-budgeted pool (miningSparkRuntime/MiningSparks.tsx) so they
don't compete with the rarer, longer-lived fracture-debris budget.

Asteroids also gain real impact physics: every hit (not just lethal
ones) now applies a knockback impulse in the shot's direction of
travel, giving asteroids a velocity they didn't have before. Tumbling-
tier asteroids share their position Vector3 between the per-asteroid
state and the render loop's rotation item, so integrating that
velocity in AsteroidBelt's existing per-frame tumble pass is enough to
visibly drift a hit rock — no separate update path needed. Dust-tier
(non-rotating) asteroids don't redraw after their initial build, so
knockback there is a no-op by design; they're too small to read
knockback on anyway.
Replaces silent health-only sub-lethal hits with genuine local
structural damage. On the first damaging hit, a tumbling-tier asteroid
(budget/quality permitting) is "promoted" out of its shared
InstancedMesh into a standalone Mesh with its own cloned, individually
mutable geometry — dust-tier rocks are excluded (78% of the
population, never redraw after initial build, too small to matter
visually). Every subsequent non-lethal hit locally dents that
geometry at the actual world-space impact point (reusing
rockGeometry.ts's own per-vertex-mutation recipe, just scoped by
distance falloff instead of applied uniformly), with a hard radial
floor so repeated hits can't collapse a vertex through the core.
Promoted asteroids also switch from a stateless clock-based tumble
formula to a real integrated spin, needed so a later momentum-based
fragment-velocity formula has an actual angular velocity to read.

Falls back gracefully to today's health-only behavior when the
promotion budget (new promotedAsteroidMax quality tier) is full or the
asteroid is dust-tier — never blocks or crashes, just skips the visual
upgrade for that hit.

This is step 1 of a larger rework (see the approved plan) replacing
"delete + spawn generic debris" with real composite-body physics;
lethal fracture still uses the old jitter-based debris spawn for now —
that becomes pattern-based chunk extraction with real momentum in the
next step.
Lethal hits no longer spawn generic jitter-only debris — they extract
real clusters of the asteroid's own geometry (precomputed once per
base shape via nearest-seed-point clustering of face centroids, cached,
then extracted fresh from the current, possibly-already-dented source
buffer at fracture time) so fragments visually read as pieces of the
specific rock that broke, not stock rubble. Fragment count is clamped
to the chosen pattern's chunk count so no two fragments share a shape.

Fragment velocity is now real rigid-body momentum instead of jitter
alone: parent linear velocity + parent angular velocity at the
fragment's offset-from-center, plus the existing impact-direction/
force bias. Fragments also carry their own spin (quaternion + angular
velocity, deterministically hashed, faster for smaller/faster ejecta)
and debris rendering now composes that rotation instead of ignoring it
entirely, which it did before this change.

Falls back to the previous jitter-only spawn when no promotion API is
registered (defensive; in practice always available once a belt is
mounted) — keeps existing debris-count/ore-fraction test coverage
valid unchanged.
…chips

Debris now reflects off planets/asteroids/other debris (reflectSphereContact,
a real bounce with restitution + tangential energy bleed) instead of
disappearing on first contact. A hard-enough hit chips 1-2 small secondary
fragments off a cascadeDepth:0 fragment, capped to exactly one extra level so
population growth stays bounded.
Pattern-based fragments now carry a shapeKey (tier/variant/pattern/cluster)
threaded through from asteroidFracture.ts; AsteroidDebris.tsx renders each
distinct shape in its own lazily-built InstancedMesh bucket (extracted from
the belt's shared base geometry) so a fragment visually reads as the actual
chunk it broke off, falling back to a generic rock bucket for jitter-only
fragments and cascade chips.
const over let for a never-reassigned array, and copy ref.current into a
local before using it inside an effect's cleanup closure.
@netlify

netlify Bot commented Jul 5, 2026

Copy link
Copy Markdown

Deploy Preview for lundstedt-solar-system ready!

Name Link
🔨 Latest commit aca4b3c
🔍 Latest deploy log https://app.netlify.com/projects/lundstedt-solar-system/deploys/6a50977253f6a800082cda8c
😎 Deploy Preview https://deploy-preview-9--lundstedt-solar-system.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

claude added 7 commits July 5, 2026 17:57
… it entirely

QUALITY.low had asteroids/debrisMax/miningVfxBudget all at 0, so on any
device that auto-detects 'low' (mobile, <=4 cores/4GB, or no WebGPU) the
entire mining/asteroid system was silently absent — no asteroids to shoot,
no sparks, no debris, matching the reported "no visible shots and no
debris." Gave low a modest nonzero budget for all three so mining stays a
functional gameplay loop on every tier, and bumped the shot-flash duration
slightly (0.06s -> 0.1s) for perceptibility.

Also wired up cascadeFractureEnabled, which was defined and documented in
quality.ts but never actually read anywhere — a dead config field from the
earlier cascade-bounce work. debrisRuntime now carries a cascadeEnabled
flag that gates secondary-fracture chip spawning (debris still always
bounces; this only caps the extra population growth on low-end tiers).
…ting

Verified via live-instrumented testing against the running app (not just
code reading) that the damage -> fracture -> debris -> render pipeline
itself works correctly end-to-end: a lethal hit properly kills the
asteroid and spawns real, well-formed fragments that get mounted for
rendering with no errors.

The reported "asteroid changes shape but no debris ever breaks off" is
explained by health tuning, not a rendering bug: the old curve (8 + r*18)
made bigger, more visibly-denting rocks take up to ~5.6s of perfectly
concentrated fire to kill. In a dense belt it's easy for aim to drift onto
a neighboring rock between shots, spreading damage thin across many
asteroids (visible dents) without ever finishing one off (no debris).
Retuned to 5 + r*10 so a realistic couple-second burst reliably kills even
the largest tier-2 rocks.
Verified live (headless-browser instrumentation against the running dev
build) that the beam's fire/flash logic was already 100% correct -
visible ~60% of frames at the intended duty cycle, always attached to the
scene - but a three.js Line is a 1px hairline regardless of the
`linewidth` material property in WebGL/WebGPU, which reads as
imperceptible at a 0.1s flash against a starfield, especially on a phone.
Swapped it for a thin CylinderGeometry mesh (oriented via quaternion,
scaled to the shot's length) so the tracer has real, guaranteed on-screen
width.
Shots now physically originate from a weapon hardpoint on the ship (not
the camera), travel through space at a finite speed (220 u/s + the ship's
own velocity — real momentum transfer), and are resolved by a per-frame
swept-segment collision test against asteroids/planets, so a fast bolt
can't tunnel through a small target between frames. Damage, knockback,
and debris still route through the exact same applyAsteroidDamage
pipeline as before, now triggered at the projectile's true point of
contact instead of an instant camera raycast.

New: src/scene/projectileRuntime.ts (pooled runtime, same convention as
debrisRuntime.ts), src/systems/projectilePhysics.ts (integrate + swept
collision, unit tested including a tunneling-prevention case),
src/scene/Projectiles.tsx (InstancedMesh render, budget-capped by the new
QUALITY[...].projectileMax tier). Added raySphereHit/raycastPlanets as
pure (non-mutating) primitives in shipCollision.ts for the projectile's
planet-collision check, factored out of the existing sphere-contact math.

The crosshair's continuous "is something targetable" aim-assist raycast
is unchanged — only the weapon's actual hit resolution moved from instant
camera-based to projectile-travel-based. SpaceMiningController.tsx no
longer renders anything itself (the flash-beam mesh is gone, superseded
by the traveling bolt); it now only owns fire cadence, hardpoint
placement, launch audio, aim telemetry, and ore magnetism/collection.
Two bugs found by instrumenting the live app, both confirmed fixed by
re-running the same end-to-end scenario (hold fire aimed at a real belt
asteroid: 16 auto-shots over a 2.5s hold, health 30 -> killed, knockback
velocity imparted, debris fragments present in the world afterward):

1. Converged aim. Projectiles spawned at the ship's hardpoint but flew
   parallel to the CAMERA ray. The chase camera sits a couple of units
   behind/above the ship, so the bolt's flight line was laterally offset
   from the crosshair line by more than a small asteroid's radius -
   shots consistently missed exactly what the reticle said was
   targetable, which is why hits (and therefore knockback and debris)
   never happened. Shots now fly from the hardpoint toward the
   crosshair's actual world target point.

2. Belt-frame/world-frame mismatch in fracture spawns. state.pos/vel are
   belt-LOCAL (the whole belt group rotates by groupYaw), but debris
   simulates in WORLD space. Fragments spawned at the local position -
   measured live at 3452 units away from the same asteroid's world
   position (groupYaw was 8.7 rad) - and were instantly removed by the
   400-unit distance cull, so even a landed kill produced zero visible
   debris. The knockback impulse had the mirror bug (world-space
   direction added to local-frame velocity, pushing rocks ~140 degrees
   off the shot line). Fragment kinematics are now computed in the belt
   frame and rotated to world for the spawn; knockback direction is
   rotated into the belt frame. Two yaw regression tests added.
Two compounding causes of "still no debris from the shots," found by
sampling debris state live across a burst-fire sequence against a real
belt asteroid:

1. Non-lethal hits (the vast majority of shots — an asteroid takes
   several hits to kill) produced zero debris at all, only a health
   decrement + dent. Every connecting hit now also chips off one small,
   short-lived fragment at the exact impact point, giving instant visual
   feedback on every shot, not just the eventual kill. Chips are pre-aged
   (spawned with `life` already advanced) so sustained fire can't fill
   the shared debris pool and starve real fracture fragments of budget.

2. Fragment ejection speed was derived directly from the projectile's own
   flight speed (~220 u/s, after the earlier projectile-weapon rework),
   uncapped — fracture fragments launched at up to ~90-150 u/s, fast
   enough to streak out of the debris-cull radius within a fraction of a
   second. Technically "spawned" (confirmed in the prior round's live
   check) but functionally invisible in normal play. Ejection energy is
   now capped at a physically-motivated fraction of the impact speed
   (MAX_EJECTION_SPEED = 30), verified live to keep fragment speeds in a
   watchable ~6-7 u/s range against a real hit sequence.

Existing "low-impact -> no debris" tests updated to the new "low tier ->
one chip" contract; two new assertions cover the chip's short lifetime
and cascade-depth-1 (never re-fractures further).
Hold-to-open radial tool selector (Q on desktop, a new touch button) — drag
to a wedge, release to select. Deliberately not modal like the existing
Backpack/Craft/Silo sheets: pointer-lock stays engaged and the game keeps
simulating while it's open, since opening it just redirects raw look-deltas
into the wheel's own drag vector instead of the camera.

Pickaxe: unchanged mining behavior, now gated to only work when it's the
equipped tool (previously mining was unconditional).

Gun: a second, longer-range mining tool (10 vs the pickaxe's 6), automatic
fire at 4 shots/sec, reusing the same aim-raycast/crack-progress/break
pipeline as the pickaxe. Reduced resource yield and can't chop trees, so
the pickaxe stays meaningful rather than being strictly superseded.

Flashlight: a real SpotLight, toggled via the existing primary-click input
(repurposed only while equipped, since mining is a no-op for this tool).
Requires a genuine darkness system, added alongside it: ambient light now
dims with cave depth (an existing depth signal that previously only fed
audio) and, independently, on bodies whose biome archetype is already
naturally dark (airless/moon-type biomes already have low authored ambient
values — no new per-body data needed). Both factors compound, so a dark
body's caves are the darkest of all.

Found and fixed a real bug along the way: the WebGPU renderer only
registers AmbientLight/PointLight/DirectionalLight with its node library
(the same class of bug already fixed for DirectionalLight earlier in this
project) — a SpotLight would have silently never lit anything without also
registering SpotLightNode.

New pure/testable modules: voxelControls.ts's resolveWheelSlice (wheel
drag-vector -> selected tool) and darkness.ts's ambientDarknessFactor
(cave + dark-body dimming), both unit tested. Verified live against the
running app (headless browser): tool equip via the wheel gesture, the
darkness formula's actual output, and that toggling the flashlight
produces no WebGPU light-node warnings.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants